<?php

/**
 * PHPTAL templating engine
 *
 * PHP Version 5
 *
 * @category HTML
 * @package  PHPTAL
 * @author   Laurent Bedubourg <lbedubourg@motion-twin.com>
 * @author   Kornel Lesiński <kornel@aardvarkmedia.co.uk>
 * @license  http://www.gnu.org/licenses/lgpl.html GNU Lesser General Public License
 * @version  SVN: $Id$
 * @link     http://phptal.org/
 */

/**
 * This class handles template execution context.
 * Holds template variables and carries state/scope across macro executions.
 *
 */
class PHPTAL_Context {

  public $repeat;
  public $_xmlDeclaration;
  public $_docType;
  private $_nothrow;
  private $_slots = array();
  private $_slotsStack = array();
  private $_parentContext = null;
  private $_globalContext = null;
  private $_echoDeclarations = false;

  public function __construct() {
    $this->repeat = new stdClass();
  }

  public function __clone() {
    $this->repeat = clone $this->repeat;
  }

  /**
   * will switch to this context when popContext() is called
   *
   * @return void
   */
  public function setParent(PHPTAL_Context $parent) {
    $this->_parentContext = $parent;
  }

  /**
   * set stdClass object which has property of every global variable
   * It can use __isset() and __get() [none of them or both]
   *
   * @return void
   */
  public function setGlobal(stdClass $globalContext) {
    $this->_globalContext = $globalContext;
  }

  /**
   * save current execution context
   *
   * @return Context (new)
   */
  public function pushContext() {
    $res = clone $this;
    $res->setParent($this);
    return $res;
  }

  /**
   * get previously saved execution context
   *
   * @return Context (old)
   */
  public function popContext() {
    return $this->_parentContext;
  }

  /**
   * @param bool $tf true if DOCTYPE and XML declaration should be echoed immediately, false if buffered
   */
  public function echoDeclarations($tf) {
    $this->_echoDeclarations = $tf;
  }

  /**
   * Set output document type if not already set.
   *
   * This method ensure PHPTAL uses the first DOCTYPE encountered (main
   * template or any macro template source containing a DOCTYPE.
   *
   * @param bool $called_from_macro will do nothing if _echoDeclarations is also set
   *
   * @return void
   */
  public function setDocType($doctype, $called_from_macro) {
    // FIXME: this is temporary workaround for problem of DOCTYPE disappearing in cloned PHPTAL object (because clone keeps _parentContext)
    if (!$this->_docType) {
      $this->_docType = $doctype;
    }

    if ($this->_parentContext) {
      $this->_parentContext->setDocType($doctype, $called_from_macro);
    } else if ($this->_echoDeclarations) {
      if (!$called_from_macro) {
        echo $doctype;
      } else {
        throw new PHPTAL_ConfigurationException("Executed macro in file with DOCTYPE when using echoExecute(). This is not supported yet. Remove DOCTYPE or use PHPTAL->execute().");
      }
    } else if (!$this->_docType) {
      $this->_docType = $doctype;
    }
  }

  /**
   * Set output document xml declaration.
   *
   * This method ensure PHPTAL uses the first xml declaration encountered
   * (main template or any macro template source containing an xml
   * declaration)
   *
   * @param bool $called_from_macro will do nothing if _echoDeclarations is also set
   *
   * @return void
   */
  public function setXmlDeclaration($xmldec, $called_from_macro) {
    // FIXME
    if (!$this->_xmlDeclaration) {
      $this->_xmlDeclaration = $xmldec;
    }

    if ($this->_parentContext) {
      $this->_parentContext->setXmlDeclaration($xmldec, $called_from_macro);
    } else if ($this->_echoDeclarations) {
      if (!$called_from_macro) {
        echo $xmldec . "\n";
      } else {
        throw new PHPTAL_ConfigurationException("Executed macro in file with XML declaration when using echoExecute(). This is not supported yet. Remove XML declaration or use PHPTAL->execute().");
      }
    } else if (!$this->_xmlDeclaration) {
      $this->_xmlDeclaration = $xmldec;
    }
  }

  /**
   * Activate or deactivate exception throwing during unknown path
   * resolution.
   *
   * @return void
   */
  public function noThrow($bool) {
    $this->_nothrow = $bool;
  }

  /**
   * Returns true if specified slot is filled.
   *
   * @return bool
   */
  public function hasSlot($key) {
    return isset($this->_slots[$key]) || ($this->_parentContext && $this->_parentContext->hasSlot($key));
  }

  /**
   * Returns the content of specified filled slot.
   *
   * Use echoSlot() whenever you just want to output the slot
   *
   * @return string
   */
  public function getSlot($key) {
    if (isset($this->_slots[$key])) {
      if (is_string($this->_slots[$key])) {
        return $this->_slots[$key];
      }
      ob_start();
      call_user_func($this->_slots[$key][0], $this->_slots[$key][1], $this->_slots[$key][2]);
      return ob_get_clean();
    } else if ($this->_parentContext) {
      return $this->_parentContext->getSlot($key);
    }
  }

  /**
   * Immediately echoes content of specified filled slot.
   *
   * Equivalent of echo $this->getSlot();
   *
   * @return string
   */
  public function echoSlot($key) {
    if (isset($this->_slots[$key])) {
      if (is_string($this->_slots[$key])) {
        echo $this->_slots[$key];
      } else {
        call_user_func($this->_slots[$key][0], $this->_slots[$key][1], $this->_slots[$key][2]);
      }
    } else if ($this->_parentContext) {
      return $this->_parentContext->echoSlot($key);
    }
  }

  /**
   * Fill a macro slot.
   *
   * @return void
   */
  public function fillSlot($key, $content) {
    $this->_slots[$key] = $content;
    if ($this->_parentContext) {
      // Works around bug with tal:define popping context after fillslot
      $this->_parentContext->_slots[$key] = $content;
    }
  }

  public function fillSlotCallback($key, $callback, $_thistpl, $tpl) {
    assert('is_callable($callback)');
    $this->_slots[$key] = array($callback, $_thistpl, $tpl);
    if ($this->_parentContext) {
      // Works around bug with tal:define popping context after fillslot
      $this->_parentContext->_slots[$key] = array($callback, $_thistpl, $tpl);
    }
  }

  /**
   * Push current filled slots on stack.
   *
   * @return void
   */
  public function pushSlots() {
    $this->_slotsStack[] = $this->_slots;
    $this->_slots = array();
  }

  /**
   * Restore filled slots stack.
   *
   * @return void
   */
  public function popSlots() {
    $this->_slots = array_pop($this->_slotsStack);
  }

  /**
   * Context setter.
   *
   * @return void
   */
  public function __set($varname, $value) {
    if (preg_match('/^_|\s/', $varname)) {
      throw new PHPTAL_InvalidVariableNameException('Template variable error \'' . $varname . '\' must not begin with underscore or contain spaces');
    }
    $this->$varname = $value;
  }

  /**
   * @return bool
   */
  public function __isset($varname) {
    // it doesn't need to check isset($this->$varname), because PHP does that _before_ calling __isset()
    return isset($this->_globalContext->$varname) || defined($varname);
  }

  /**
   * Context getter.
   * If variable doesn't exist, it will throw an exception, unless noThrow(true) has been called
   *
   * @return mixed
   */
  public function __get($varname) {
    // PHP checks public properties first, there's no need to support them here
    // must use isset() to allow custom global contexts with __isset()/__get()
    if (isset($this->_globalContext->$varname)) {
      return $this->_globalContext->$varname;
    }

    if (defined($varname)) {
      return constant($varname);
    }

    if ($this->_nothrow) {
      return null;
    }

    throw new PHPTAL_VariableNotFoundException("Unable to find variable '$varname' in current scope");
  }

  /**
   * helper method for PHPTAL_Context::path()
   *
   * @access private
   */
  private static function pathError($base, $path, $current, $basename) {
    if ($current !== $path) {
      $pathinfo = " (in path '.../$path')";
    } else
      $pathinfo = '';

    if (!empty($basename)) {
      $basename = "'" . $basename . "' ";
    }

    if (is_array($base)) {
      throw new PHPTAL_VariableNotFoundException("Array {$basename}doesn't have key named '$current'$pathinfo");
    }
    if (is_object($base)) {
      throw new PHPTAL_VariableNotFoundException(ucfirst(get_class($base)) . " object {$basename}doesn't have method/property named '$current'$pathinfo");
    }
    throw new PHPTAL_VariableNotFoundException(trim("Attempt to read property '$current'$pathinfo from " . gettype($base) . " value {$basename}"));
  }

  /**
   * Resolve TALES path starting from the first path element.
   * The TALES path : object/method1/10/method2
   * will call : $ctx->path($ctx->object, 'method1/10/method2')
   *
   * This function is very important for PHPTAL performance.
   *
   * This function will become non-static in the future
   *
   * @param mixed  $base    first element of the path ($ctx)
   * @param string $path    rest of the path
   * @param bool   $nothrow is used by phptal_exists(). Prevents this function from
   * throwing an exception when a part of the path cannot be resolved, null is
   * returned instead.
   *
   * @access private
   * @return mixed
   */
  public static function path($base, $path, $nothrow = false) {
    if ($base === null) {
      if ($nothrow)
        return null;
      PHPTAL_Context::pathError($base, $path, $path, $path);
    }

    $chunks = explode('/', $path);
    $current = null;

    for ($i = 0; $i < count($chunks); $i++) {
      $prev = $current;
      $current = $chunks[$i];

      // object handling
      if (is_object($base)) {
        $base = phptal_unravel_closure($base);

        // look for method. Both method_exists and is_callable are required because of __call() and protected methods
        if (method_exists($base, $current) && is_callable(array($base, $current))) {
          $base = $base->$current();
          continue;
        }

        // look for property
        if (property_exists($base, $current)) {
          $base = $base->$current;
          continue;
        }

        if ($base instanceof ArrayAccess && $base->offsetExists($current)) {
          $base = $base->offsetGet($current);
          continue;
        }

        if (($current === 'length' || $current === 'size') && $base instanceof Countable) {
          $base = count($base);
          continue;
        }

        // look for isset (priority over __get)
        if (method_exists($base, '__isset')) {
          if ($base->__isset($current)) {
            $base = $base->$current;
            continue;
          }
        }
        // ask __get and discard if it returns null
        elseif (method_exists($base, '__get')) {
          $tmp = $base->$current;
          if (null !== $tmp) {
            $base = $tmp;
            continue;
          }
        }

        // magic method call
        if (method_exists($base, '__call')) {
          try {
            $base = $base->__call($current, array());
            continue;
          } catch (BadMethodCallException $e) {
            
          }
        }

        if ($nothrow) {
          return null;
        }

        PHPTAL_Context::pathError($base, $path, $current, $prev);
      }

      // array handling
      if (is_array($base)) {
        // key or index
        if (array_key_exists((string) $current, $base)) {
          $base = $base[$current];
          continue;
        }

        // virtual methods provided by phptal
        if ($current == 'length' || $current == 'size') {
          $base = count($base);
          continue;
        }

        if ($nothrow)
          return null;

        PHPTAL_Context::pathError($base, $path, $current, $prev);
      }

      // string handling
      if (is_string($base)) {
        // virtual methods provided by phptal
        if ($current == 'length' || $current == 'size') {
          $base = strlen($base);
          continue;
        }

        // access char at index
        if (is_numeric($current)) {
          $base = $base[$current];
          continue;
        }
      }

      // if this point is reached, then the part cannot be resolved

      if ($nothrow)
        return null;

      PHPTAL_Context::pathError($base, $path, $current, $prev);
    }

    return $base;
  }

}

/**
 * @see PHPTAL_Context::path()
 * @deprecated
 */
function phptal_path($base, $path, $nothrow = false) {
  return PHPTAL_Context::path($base, $path, $nothrow);
}

/**
 * helper function for chained expressions
 *
 * @param mixed $var value to check
 * @return bool
 * @access private
 */
function phptal_isempty($var) {
  return $var === null || $var === false || $var === '' || ((is_array($var) || $var instanceof Countable) && count($var) === 0);
}

/**
 * helper function for conditional expressions
 *
 * @param mixed $var value to check
 * @return bool
 * @access private
 */
function phptal_true($var) {
  $var = phptal_unravel_closure($var);
  return $var && (!$var instanceof Countable || count($var));
}

/**
 * convert to string and html-escape given value (of any type)
 *
 * @access private
 */
function phptal_escape($var, $encoding) {
  if (is_string($var)) {
    return htmlspecialchars($var, ENT_QUOTES, $encoding);
  }
  return htmlspecialchars(phptal_tostring($var), ENT_QUOTES, $encoding);
}

/**
 * convert anything to string
 *
 * @access private
 */
function phptal_tostring($var) {
  if (is_string($var)) {
    return $var;
  } elseif (is_bool($var)) {
    return (int) $var;
  } elseif (is_array($var)) {
    return implode(', ', array_map('phptal_tostring', $var));
  } elseif ($var instanceof SimpleXMLElement) {

    /* There is no sane way to tell apart element and attribute nodes
      in SimpleXML, so here's a guess that if something has no attributes
      or children, and doesn't output <, then it's an attribute */

    $xml = $var->asXML();
    if ($xml[0] === '<' || $var->attributes() || $var->children()) {
      return $xml;
    }
  }
  return (string) phptal_unravel_closure($var);
}

/**
 * unravel the provided expression if it is a closure
 *
 * This will call the base expression and its result
 * as long as it is a Closure.  Once the base (non-Closure)
 * value is found it is returned.
 *
 * This function has no effect on non-Closure expressions
 */
function phptal_unravel_closure($var) {
  while ($var instanceof Closure) {
    $var = $var();
  }
  return $var;
}
